#!/usr/bin/env python3

import os
import re
import sys
import time
import threading
import subprocess


class Util:

    @staticmethod
    def rangeMayInfinite(start, end):
        assert start == 0 and end >= 0

        i = 0
        while end == 0 or i < end:
            yield i
            if i >= sys.maxsize:
                raise ValueError("value too large")
            i += 1

    @staticmethod
    def mergeDict(dict1, dict2):
        ret = dict(dict1)
        for k, v in dict2.items():
            if k not in ret:
                ret[k] = v
        return ret


class TeeThread(threading.Thread):

    def __init__(self, fd, out_fd, name):
        super().__init__(name='git.tee.%s' % (name))
        self.data = None
        self.fd = fd
        self.out_fd = out_fd

    def run(self):
        chunks = []
        for line in self.fd:
            chunks.append(line)
            self.out_fd.write(line)
        self.data = ''.join(chunks)


class GitHttpConnectionTimeoutExtension:

    @staticmethod
    def additional_environ():
        return {
            "GIT_HTTP_LOW_SPEED_LIMIT": "1024",
            "GIT_HTTP_LOW_SPEED_TIME": "60",            # we don't use GIT_HTTP_CONNECT_TIMEOUT as git network operation is not "continuable"
            "GIT_HTTP_CONNECT_TIMEOUT": "10",           # only has effect for patched git (https://stackoverflow.com/questions/28180013/can-you-specify-a-timeout-to-git-fetch)
        }


class GitRetry:

    GIT_EXE = '/usr/libexec/git-core/git'

    DEFAULT_DELAY_SECS = 3.0

    DEFAULT_RETRY_COUNT = 0
    #DEFAULT_RETRY_COUNT = 5

    DEFAULT_DELAY_FACTOR = 2

    MAX_DELAY_FACTOR_ITERATION = 3

    # Retry a git operation if git returns a error response with any of these
    # messages. It's all observed 'bad' GoB responses so far.
    #
    # This list is from Infra Git wrapper:
    # https://chromium.googlesource.com/infra/infra/+/HEAD/go/src/infra/tools/git
    GIT_TRANSIENT_ERRORS_RE = re.compile('|'.join([
        # crbug.com/285832
        r'!.*\[remote rejected\].*\(error in hook\)',
        # crbug.com/289932
        r'!.*\[remote rejected\].*\(failed to lock\)',
        # crbug.com/307156
        r'!.*\[remote rejected\].*\(error in Gerrit backend\)',
        # crbug.com/285832
        r'remote error: Internal Server Error',
        # crbug.com/294449
        r'fatal: Couldn\'t find remote ref ',
        # crbug.com/220543
        r'git fetch_pack: expected ACK/NAK, got',
        # crbug.com/189455
        r'protocol error: bad pack header',
        # crbug.com/202807
        r'The remote end hung up unexpectedly',
        # crbug.com/298189
        r'TLS packet with unexpected length was received',
        # crbug.com/187444
        r'RPC failed; result=\d+, HTTP code = \d+',
        # crbug.com/388876
        r'Connection timed out',
        # crbug.com/430343
        # TODO(dnj): Resync with Chromite.
        r'The requested URL returned error: 5\d+',
        r'Connection reset by peer',
        r'Unable to look up',
        r'Couldn\'t resolve host',
        r'Could not resolve host',
        r'Timeout was reached',
        # http-timeout-extension
        r'Operation too slow',
        # additional
        r'OpenSSL SSL_read: SSL_ERROR_SYSCALL, errno 0',
        r'Error in the HTTP2 framing layer',
    ]), re.IGNORECASE)

    def __init__(self, retry_count=None, delay=None, delay_factor=None):
        self.retry_count = retry_count if retry_count is not None else self.DEFAULT_RETRY_COUNT     # self.retry_count can be zero, which means retry infinitely
        self.delay = delay if delay is not None else self.DEFAULT_DELAY_SECS
        self.delay_factor = delay_factor if delay_factor is not None else self.DEFAULT_DELAY_FACTOR

        assert self.retry_count >= 0
        assert self.delay >= 0
        assert self.delay_factor >= 0

    def run(self, args):
        returncode = 0

        for i in Util.rangeMayInfinite(0, self.retry_count):
            # If the previous run failed and a delay is configured, delay before the next run.
            delay = self._computeDelay(i)
            if delay > 0:
                print("Delaying for %s second(s) until next retry" % (delay))
                time.sleep(delay)

            returncode, _, stderr = self._execute(args)
            if returncode == 0:
                break
            if not self._shouldRetry(stderr):
                return returncode

        return returncode

    def _shouldRetry(self, stderr):
      return self.GIT_TRANSIENT_ERRORS_RE.search(stderr) is not None

    def _execute(self, args):
        args = [self.GIT_EXE] + args
        proc = subprocess.Popen(
            args,
            stderr=subprocess.PIPE,
            text=True,
            env=Util.mergeDict(os.environ, GitHttpConnectionTimeoutExtension.additional_environ()),
        )
        stderr_tee = TeeThread(proc.stderr, sys.stderr, 'stderr')
        # Start our process. Collect/tee 'stdout' and 'stderr'.
        stderr_tee.start()
        try:
          proc.wait()
        except KeyboardInterrupt:
          proc.terminate()
          proc.wait()
        finally:
          stderr_tee.join()
        return proc.returncode, None, stderr_tee.data

    def _computeDelay(self, iteration):
        """Returns: the delay (in seconds) for a given iteration. The first iteration has a delay of '0'.
          Args: iteration: (int) The iteration index (starting with zero as the first iteration).
        """

        if self.delay == 0 or iteration == 0:
            return 0

        if self.delay_factor == 0:
            # Linear delay
            return iteration * self.delay
        else:
            # Exponential delay
            iteration = min(iteration, self.MAX_DELAY_FACTOR_ITERATION)
            return (self.delay_factor ** (iteration - 1)) * self.delay


if __name__ == '__main__':
    obj = GitRetry()
    returncode = obj.run(sys.argv[1:])
    sys.exit(returncode)
